Skip to content

fix(objectql): multi update 的 SET 载荷剥掉非 id 的 data.id (#6262) - #6433

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-6262-multi-payload-id-strip
Aug 7, 2026
Merged

fix(objectql): multi update 的 SET 载荷剥掉非 id 的 data.id (#6262)#6433
baozhoutao merged 2 commits into
mainfrom
claude/issue-6262-multi-payload-id-strip

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #6262

按分诊评论「Scope as queued = route A only」执行:只做 A 案(派发层剥离),零 verdict 变更,B 案(响亮拒绝)不在本次范围。

问题

update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })派发#5748 裁 A / PR #5919 起就是对的:算子对象不是主键,不再遮蔽派发阶梯,声明的 bulk intent 照做,调用落到 driver.updateMany#5919 没做、#5922 也按 PD #10 明确留在范围外的,是载荷那一半。

实测复现(worktree @ origin/main,记录型 driver 驱动真实引擎,新增测试在打补丁前的失败断言原文):

AssertionError: SET payload was {"id":{"$in":["a","b"]},"title":"x"}: expected true to be false

与 issue 正文的 PROBE 逐字一致:驱动被要求把一个序列化的算子对象写进每一条命中行的主键列。五个后端会对这件事各给一个答案(#5240 / #4434 家族),而在接受它的后端上,命中行的身份不可逆地丢失。

修法

packages/objectql/src/engine.ts 的 update multi 分支载荷组装点(分支第一件事,encryptSecretFields 之前):载荷带 id 键时剥掉,并按 warn 记一条点明后果与两种正确写法的日志。

论证只有一句:走到 multi 分支本身就意味着 resolveEngineUpdateDispatch 答了 multi,即它在两个 id 来源里都没找到真值标量 id —— 所以此刻 data.id 里的任何东西(算子对象、数组、null、假值标量)都是引擎已经裁定不是主键的值。剥离是同一个问题的同一个答案多用在一层上,不是第二个答案:不是主键的东西,也就不该坐在主键列上。

必答项一:与 #5922 / #5748 的语义一致性

#5748(裁 A / PR #5919)一致 —— 这是它的另一半,不是它的回退。
#5748data.id 送进了和 where.id 同一个标量测试,答案是「这个 data.id 不是主键」;它据此做了一件事(不再遮蔽阶梯,multi 照做)。本 PR 据同一个答案做第二件事(不再留在主键列位置)。ENGINE_UPDATE_DISPATCH_CASES 一行未动,operator object in data.id WITH multi:true 仍是 'multi',engine-update-dispatch.test.ts 用真实引擎逐条驱动的 25 例全绿(该文件 36/36)。反过来的 B 案要反转这条刚落地的 case,那才是对裁 A 的部分回退。

#5922 一致 —— 它留下的正是这条轴,而不是校验轴。
#5922 收口的是「声明值为标量的字段」上的算子对象,走 record-validator;idSKIP_FIELDS 按设计跳过(引擎自有列),因为这一格的裁定写在派发层。若改在 record-validator 里拒收同一个调用,就是对同一个问题给出第二个答案 —— 正是 engine-update-dispatch.ts 这一族模块被抽出来防止的事(#4550 / #4434)。本 PR 落在派发已给出的答案上,record-validator.ts 一字未动,两条轴仍各管各的。

「一个问题一个答案」不被破坏的可检验形式:唯一的判定仍只有 resolveEngineUpdateDispatch 一处;剥离不重新问「这是不是 id」,而是消费分支本身携带的答案(kind === 'multi' ⇒ 无 id)。代码里没有第二个标量测试、没有 ?? 兜底、没有手抄的 if

必答项二:B 案(响亮拒绝)将来若裁定,要动哪里(只答不做)

  1. packages/metadata-core/src/engine-update-dispatch.ts —— 判定本体。resolveEngineUpdateDispatch 需要新增一个 reject 前置:data.id 存在且非标量真值 options.multi 为真 ⇒ reject(今天这条落在 if (options?.multi) return { kind: 'multi' })。同文件的 ENGINE_UPDATE_DISPATCH_CASESoperator object in data.id WITH multi:truearray data.id with multi:true 两例的 expect'multi' 改为 'reject',模块头 point 2 的叙述需要重写(「不再遮蔽阶梯」变成「非标量 data.id 本身即拒绝理由」)。新拒绝语句大概率需要一条独立的 message 常量,而不是复用 ENGINE_UPDATE_REJECT_MESSAGE(「既没点名一行也没声明 bulk」和「声明了 bulk 但载荷里塞了个非 id 的 id」是两种不同的作者错误,共用一句话会把诊断打回原点)。
  2. packages/objectql/src/engine.ts —— 生产者侧。update() 末尾那条 else { throw new Error(ENGINE_UPDATE_REJECT_MESSAGE) } 是 hook 改写后重问判定的地方,需要按新 message 分叉;本 PR 加的剥离块整块删除(拒绝之后没有载荷可剥)。
  3. 拒绝面的连带:所有钉在 assertEngineUpdateDispatch 上的假引擎自动跟进(这正是该模块存在的理由,不需要逐个改);但 scripts/check-engine-double-contract.mjs 的 DEBT 账本里那 133 条未钉的替身会开始与生产者分歧,需要重新测量。
  4. 消费者侧诊断:REST/flow update_record 把新拒绝映射成 4xx 而非 500 —— 属 packages/restmapDataError 与 automation 侧执行器,拒绝语义落地时才有意义。
  5. 需要新裁决的原因(不是工作量):B 是对 ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 裁 A 的部分回退,而 A 与 B 在同一个业务场景上给用户不同的东西 —— A 让「声明了 bulk intent 就照做」继续成立(作者多写了个 id 谓词,行集由 where 决定),B 认为这种调用形状本身即作者错误、必须响亮。这是产品判断,不是实现判断。

变更清单

文件 改动
packages/objectql/src/engine.ts multi 分支载荷组装点新增 id 剥离 + 论证注释(+51)
packages/objectql/src/engine-update-multi-payload-id.test.ts 新增,11 例(+227)
.changeset/engine-update-multi-payload-id-strip.md 新增,@objectstack/objectql patch

⛔ 未触碰:ENGINE_UPDATE_DISPATCH_CASES / metadata-core 全包、record-validator.ts、非 multi 路径、事务区(#6403)、自增、剥离时序区(#5591 / #6343)、summary、content/docs/releases/

测试

新增 packages/objectql/src/engine-update-multi-payload-id.test.ts,11 例三组:

  1. PROBE 钉死:算子对象 / 数组 / nulldata.id + multiupdateMany 载荷id,title 照常落地;调用方传入的载荷对象不被就地改写(剥离走浅拷贝,与本路径其它 strip 一致)。
  2. 无 id 与 where.id 侧不变:multi 且载荷从未带 id ⇒ 载荷与行域 AST 双双原样;where: { id: { $in: [...] } } 仍由 AST 选行,载荷不动。
  3. 假值标量与单 id 路径:{ id: 0 } / { id: '' } + multi判定仍是 multi(engine-delete-dispatch 的共享判定与 ObjectQL.delete 在「假值标量 id」上不一致 —— where: { id: 0 } 判定答 by-id,引擎却 reject #5747 / ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 语义,原样);单 id 路径(data.id 标量压过 multiwhere.id 标量、以及 ObjectQL.update 的 data.id 不做标量测试 —— 载荷里的算子对象被当成主键绑定,且盖过显式 options.multi: true #5748 的「算子 data.id 旁有标量 where.id」头号形状)全部 driver.update,载荷按原样送达 —— 主键走独立参数,载荷里的 id 是冗余而非破坏,本 PR 不动它,并按现状钉死,使将来任何扩大剥离范围的动作都必须是刻意的。

反向验证(方向:红,如预测)

肢 A = 去掉剥离。这次的测量顺序天然就是这个实验:测试文件先在未打补丁origin/main 上跑,engine.ts 一字未改 —— 5 例红,且失败信息直接印出问题载荷:

Test Files  1 failed (1)
     Tests  5 failed | 6 passed (11)

FAIL  the PROBE shape: operator-object data.id + multi:true reaches updateMany with NO id in the payload
AssertionError: SET payload was {"id":{"$in":["a","b"]},"title":"x"}: expected true to be false

打补丁后同一文件 11/11 绿。注意 does not mutate the payload object the CALLER handed in 一例在补丁就是绿的(引擎当时根本不剥,自然不会改到调用方对象)—— 它是对修法的护栏,不是复现用例,如实记在此处而非充作反向证据。

同一次运行还证明了「只有这 5 例动了」:补丁前整包 5 failed | 2327 passed (2332),补丁后 2332 passed (2332),总数一致。

命令与实测输出

pnpm --filter @objectstack/objectql test         → Test Files 141 passed (141) / Tests 2332 passed (2332)
pnpm --filter @objectstack/objectql typecheck    → tsc --noEmit,无输出
pnpm check:engine-double-contract                → OK — 80 pinned, 133 in the DEBT ledger, 4 exempt
node scripts/check-nul-bytes.mjs                 → OK (scanned 6070 tracked text file(s))
npx eslint (两个改动文件)                          → exit 0

消费半径(multi 分支的下游调用者)另跑:

pnpm --filter @objectstack/rest test                 → Test Files 64 passed (64) / Tests 881 passed (881)
pnpm --filter @objectstack/service-automation test   → Test Files 68 passed (68) / Tests 806 passed (806)

合并 origin/main(至 7618ee814)后按 AGENTS.md §10 重跑:packages/spec 在对侧动过,故 pnpm --filter @objectstack/spec build && check:generatedAll 10 generated artifacts are up to date;objectql 全量 test + typecheck 复跑仍全绿。


Generated by Claude Code

claude added 2 commits August 7, 2026 18:23
…payload (#6262)

`update(o, { id: { $in: ['a','b'] }, title: 'x' }, { multi: true })` has
dispatched correctly since #5748 / PR #5919 — an operator object is not a
primary key, so it stops shadowing the ladder and the declared bulk intent is
honoured (`driver.updateMany`). What that fix did not do is clean the PAYLOAD.
Measured on origin/main with a recording driver over the real engine:

    updateMany({ object: 'probe_task' }, { id: { $in: ['a','b'] }, title: 'x' })

i.e. the driver is asked to write a serialized operator object into the
primary-key column of every matched row. Five backends would each answer that
differently (the #5240 / #4434 family), and on the ones that accept it the
matched rows lose their identity irreversibly.

Reaching the multi branch AT ALL means `resolveEngineUpdateDispatch` returned
`multi`, i.e. it found no scalar truthy id in EITHER source — so whatever sits
in `data.id` there is a value the engine has already RULED is not a primary
key. The strip is that same answer applied one layer on, not a second opinion:
a value that is not the primary key does not get to sit in the primary-key
column either.

- Zero verdict change: `ENGINE_UPDATE_DISPATCH_CASES` is untouched and
  `operator object in data.id WITH multi:true` still expects 'multi'.
  Rejecting the call instead (#6262 route B) would reverse that just-landed
  case — a partial rollback of #5748's ruling A, which needs a fresh decision.
- No reachable legitimate write is lost: a truthy scalar `data.id` outranks
  both `where` and `multi` and never reaches this branch, and N rows cannot
  share one primary key anyway.
- The by-id path is unchanged and pinned as-is: `driver.update` takes the
  primary key in its own argument, so the key in the payload is redundant
  rather than damaging.
- Falsy scalars keep the #5747 / #5748 dispatch semantics (still 'multi') and
  are stripped on the same argument — stripping operator objects while leaving
  `{ id: 0 }` in would be a second rule about one fact.

The drop logs at warn, naming the consequence and both correct spellings.
Deliberately not routed through `onFieldsDropped`: `DroppedFieldsEvent.reason`
is a closed enum over the two read-only strips (#3407 / #3042), and widening
that vocabulary is a `packages/spec` change with its own consumers.

Fixes #6262

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:30pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

14 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

范围外发现(PD #10,均未在本 PR 内修改)


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review August 7, 2026 18:38
@baozhoutao
baozhoutao enabled auto-merge August 7, 2026 18:38
@baozhoutao
baozhoutao added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 2a0d65e Aug 7, 2026
25 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-6262-multi-payload-id-strip branch August 7, 2026 19:01
lemonhub-io pushed a commit to OpenFork-org/objectstack that referenced this pull request Aug 8, 2026
…e payload (objectstack-ai#6435) (objectstack-ai#6475)

The by-id half of objectstack-ai#6262 / PR objectstack-ai#6433. When `data.id` is a non-scalar (operator
object, array, `null`) or a falsy scalar and `options.where.id` is a truthy
scalar, `resolveEngineUpdateDispatch` correctly rules the payload value is not
a primary key and binds `where.id` instead (objectstack-ai#5748 / PR objectstack-ai#5919). The dispatch was
right; the PAYLOAD was never cleaned, so `driver.update(object, 'rec_1', data)`
carried the ruled-not-an-id value into the SET clause and driver-sql wrote
`UPDATE task SET id = '{"$in":["a","b"]}' WHERE id = 'rec_1'` — the row's
identity overwritten irreversibly.

Route A only: strip that payload `id`, on a copy, leaving a truthy scalar
`data.id` exactly as it was (there the payload key IS the bound id — a
same-value no-op). Zero dispatch verdicts change; membership is asked by
calling the producer's own `resolveEngineUpdateDispatch`, never by re-deriving
the unexported scalar test.


Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data.id 是算子对象 + multi: true 时,{"$in":[...]} 作为普通列进入 updateMany 的 SET 载荷,写向主键列

2 participants